Understanding Error Handling, Memory Management, and ARC in Swift
Course: iOS App Development
Topic: Understanding Error Handling, Memory Management, and Automatic Reference Counting (ARC)
Course Link: iOS App Development Training at JustAcademy
Demo Link: Register for a Course Demo
Official Website: JustAcademy
1. Introduction
Error handling and memory management are fundamental concepts in Swift programming and native iOS application development. Error handling allows an application to respond to problems such as invalid input, missing files, failed network requests, and unavailable resources. Memory management ensures that objects and other data use memory efficiently throughout an application's lifecycle.
Swift provides a structured error-handling system using throw, throws, try, do, and catch. It also uses Automatic Reference Counting (ARC) to manage the lifetime of class instances. Understanding these concepts helps developers build applications that are reliable, responsive, and less likely to crash or consume excessive memory.
For example, a weather application may encounter a network failure while downloading weather information. The application should display an appropriate message instead of terminating unexpectedly. At the same time, it must manage references to view controllers, network services, and callbacks correctly so that unused objects can be released.
2. Learning Objectives
- Understand errors and the importance of error handling in Swift.
- Define custom errors using the
Error protocol.
- Use
throw, throws, try, do, and catch.
- Understand
try?, try!, defer, and the Result type.
- Differentiate value types and reference types in memory management.
- Understand how ARC manages class instances.
- Differentiate strong, weak, and unowned references.
- Identify and resolve strong reference cycles and memory leaks.
- Understand closure capture lists and their relationship to ARC.
- Apply error handling and memory-management techniques to iOS applications.
3. Understanding Errors in Swift
An error represents a condition that prevents an operation from completing successfully. Errors can occur because of invalid user input, unavailable resources, network failures, incorrect data formats, or business-rule violations.
3.1 Common Error Scenarios
| Error Scenario |
Example |
Possible Response |
| Invalid input |
A user enters letters where a number is required. |
Display a validation message. |
| File failure |
A requested document does not exist. |
Show a file-not-found message or offer another file. |
| Network failure |
A request times out or the device is offline. |
Show an error state and offer retry functionality. |
| Authentication failure |
A login request contains invalid credentials. |
Display a suitable message without exposing sensitive information. |
| Data conversion failure |
A string cannot be converted into an integer. |
Return an optional value or report a validation error. |
| Business-rule violation |
A user attempts to withdraw more money than the available balance. |
Reject the operation and explain the restriction. |
3.2 Why Error Handling Is Important
- Reliability: Prevents expected failures from unnecessarily terminating application workflows.
- User experience: Provides useful feedback when an operation fails.
- Debugging: Helps developers identify the cause of a failure.
- Security: Reduces the risk of exposing internal implementation details through error messages.
- Recovery: Makes retrying, correcting input, or choosing an alternative action possible.
- Maintainability: Makes failure paths explicit and easier to test.
4. Swift's Error Protocol
Swift represents thrown errors using types that conform to the Error protocol. Enumerations are often convenient because they allow related error cases to be grouped together.
enum LoginError: Error {
case emptyUsername
case emptyPassword
case invalidCredentials
case accountLocked
}
This enumeration defines four possible login failures. A function can throw one of these cases when the corresponding condition occurs.
4.1 Adding Associated Values to Errors
Error cases can contain associated values that provide additional information about a failure.
enum RegistrationError: Error {
case invalidEmail(String)
case passwordTooShort(minimumLength: Int)
case ageRestriction(requiredAge: Int)
}
Associated values help communicate useful details to the calling code. Avoid placing passwords, authentication tokens, or other sensitive information in errors or logs.
5. Throwing Errors with throw and throws
The throw keyword signals that an operation has failed. The throws keyword in a function declaration indicates that the function can propagate an error to its caller.
enum AgeError: Error {
case underage
}
func verifyAge(_ age: Int) throws -> String {
guard age >= 18 else {
throw AgeError.underage
}
return "Age verification successful."
}
In this example, verifyAge() returns a success message when the age is at least 18. Otherwise, it throws the underage error.
5.1 Calling a Throwing Function
A throwing function must be called using an appropriate error-handling form, such as try within a do-catch statement.
do {
let message = try verifyAge(21)
print(message)
} catch AgeError.underage {
print("You must be at least 18 years old.")
} catch {
print("An unexpected error occurred.")
}
Output:
Age verification successful.
If the function is called with an age below 18, the matching catch block handles the error instead.
6. Handling Errors with do-catch
The do-catch statement provides a structured way to handle errors. The do block contains statements that may throw, and the catch blocks define how different errors should be handled.
enum PaymentError: Error {
case invalidAmount
case insufficientBalance
}
func makePayment(amount: Double, balance: Double) throws -> Double {
guard amount > 0 else {
throw PaymentError.invalidAmount
}
guard amount <= balance else {
throw PaymentError.insufficientBalance
}
return balance - amount
}
do {
let remaining = try makePayment(amount: 200, balance: 1000)
print("Remaining balance: \(remaining)")
} catch PaymentError.invalidAmount {
print("Enter a valid payment amount.")
} catch PaymentError.insufficientBalance {
print("Insufficient balance.")
} catch {
print("Payment could not be completed.")
}
Output:
Remaining balance: 800.0
6.1 How do-catch Works
- The throwing function checks the requested payment.
- If a condition fails, it throws the relevant error.
- Swift stops executing the remaining statements in the current throwing path and propagates the error.
- A matching
catch block handles the error.
- If no error is thrown, the statements after the successful call continue executing.
7. Using try, try?, and try!
Swift provides three forms of try. They express different ways of working with a throwing operation.
| Form |
Behavior |
Typical Use |
try |
Calls a throwing operation and requires the error to be handled or propagated. |
Normal application error handling. |
try? |
Converts success into an optional result and a thrown error into nil. |
Optional results when error details are not required. |
try! |
Assumes the operation will succeed; a thrown error causes a runtime failure. |
Rare cases where success is guaranteed by a reliable invariant. |
7.1 Using try?
func convertToNumber(_ text: String) throws -> Int {
guard let number = Int(text) else {
throw NSError(
domain: "NumberConversion",
code: 1,
userInfo: nil
)
}
return number
}
let validNumber = try? convertToNumber("123")
let invalidNumber = try? convertToNumber("hello")
print(validNumber as Any)
print(invalidNumber as Any)
Output:
Optional(123)
nil
try? is useful when a failed operation can be represented simply by the absence of a value. It discards the original error details, so use do-catch when the cause matters.
7.2 Using try!
func calculateSquareRootInput(_ value: Int) throws -> Int {
guard value >= 0 else {
throw NSError(
domain: "InputError",
code: 1,
userInfo: nil
)
}
return value
}
let guaranteedValue = try! calculateSquareRootInput(25)
print(guaranteedValue)
Output:
25
This example succeeds because the supplied input meets the function's condition. If the function throws, try! causes a runtime failure. Avoid using it with user input, network responses, file operations, or other conditions that can fail unexpectedly.
8. Propagating Errors with throws
Sometimes a function should not handle an error itself. Instead, it can propagate the error to its caller, which may have more context to decide what to do.
func loadUserData() throws -> String {
throw NSError(
domain: "UserDataError",
code: 404,
userInfo: [
NSLocalizedDescriptionKey: "User data was not found."
]
)
}
func prepareProfileScreen() throws -> String {
let data = try loadUserData()
return "Profile loaded: \(data)"
}
do {
print(try prepareProfileScreen())
} catch {
print("Unable to load the profile.")
}
The error travels from loadUserData() through prepareProfileScreen() until it reaches the code that handles it. This keeps lower-level operations focused on their responsibilities.
9. Using defer for Cleanup
The defer statement schedules code to run when the current scope exits. It is useful for cleanup operations such as releasing resources or recording the completion of an operation.
func processDocument() {
print("Opening document.")
defer {
print("Closing document resources.")
}
print("Processing document.")
}
processDocument()
Output:
Opening document.
Processing document.
Closing document resources.
A deferred statement executes when the surrounding scope exits, whether it exits normally or because an error is thrown. When multiple defer statements are registered in the same scope, they execute in reverse order of registration.
9.1 Using defer with Errors
enum FileProcessingError: Error {
case invalidContent
}
func processFile() throws {
print("File processing started.")
defer {
print("Cleanup completed.")
}
throw FileProcessingError.invalidContent
}
do {
try processFile()
} catch {
print("File processing failed.")
}
Output:
File processing started.
Cleanup completed.
File processing failed.
The deferred cleanup runs before control reaches the caller's catch block. For APIs such as file handling that already manage resources through suitable abstractions, prefer those APIs rather than implementing unnecessary manual cleanup.
10. Using the Result Type
The Result type represents the outcome of an operation as either .success or .failure. It is useful when success or failure needs to be stored, passed to another function, or delivered through a callback.
enum DivisionError: Error {
case divisionByZero
}
func divide(_ numerator: Double, by denominator: Double) -> Result {
guard denominator != 0 else {
return .failure(.divisionByZero)
}
return .success(numerator / denominator)
}
let result = divide(10, by: 2)
switch result {
case .success(let value):
print("Result: \(value)")
case .failure(.divisionByZero):
print("Cannot divide by zero.")
}
Output:
Result: 5.0
Unlike a thrown error, a Result value explicitly carries success or failure as data. Choose the form that best matches the API and how the caller needs to handle the outcome.
11. Error Handling in iOS Applications
iOS applications commonly perform operations that can fail, such as reading files, decoding JSON, accessing remote services, or saving data. A reliable application handles these failures and gives users useful next steps.
11.1 JSON Decoding Example
import Foundation
struct Weather: Decodable {
let city: String
let temperature: Double
}
let json = """
{
"city": "Mumbai",
"temperature": 31.5
}
"""
do {
let data = Data(json.utf8)
let weather = try JSONDecoder().decode(
Weather.self,
from: data
)
print("City: \(weather.city)")
print("Temperature: \(weather.temperature)")
} catch {
print("Unable to read weather data: \(error.localizedDescription)")
}
Output:
City: Mumbai
Temperature: 31.5
If the JSON is malformed or its fields do not match the model, decoding throws an error. The catch block handles the failure rather than allowing it to escape without a response.
11.2 Error Handling Best Practices for iOS
- Distinguish between errors that users can correct and errors that require a retry or technical support.
- Show concise, user-friendly messages instead of raw internal error descriptions.
- Log relevant diagnostic information without exposing passwords, access tokens, or personal data.
- Use retry logic carefully and avoid repeatedly retrying permanent failures.
- Provide loading, success, empty, and failure states for network-driven screens.
- Test offline conditions, invalid responses, timeouts, and malformed data.
12. Understanding Memory Management
Memory management is the process of allocating, using, and releasing memory as an application runs. An iOS app uses memory for objects, collections, strings, images, view hierarchies, network data, and other runtime values.
Good memory management helps keep an application responsive and reduces the risk of excessive memory use, slow performance, and operating-system termination under memory pressure.
12.1 Common Memory Areas and Concepts
| Concept |
Description |
Example |
| Stack |
Commonly holds function-call frames and local execution data. |
Local variables and call information. |
| Heap |
Stores dynamically allocated data, including class instances. |
Objects created from classes. |
| Value type |
Values are copied according to value semantics. |
Structures and enumerations. |
| Reference type |
Variables can refer to the same instance. |
Class instances. |
| Deallocation |
Releasing storage when it is no longer needed. |
A class instance becoming eligible for ARC deallocation. |
| Memory leak |
Memory remains retained even though the application no longer needs the object. |
A strong reference cycle. |
This is a conceptual overview rather than a strict map of every Swift value to a single physical memory region. Actual storage decisions depend on compiler optimization and runtime implementation.
13. Value Types and Reference Types
Swift distinguishes between value types and reference types. This distinction affects how data is copied, shared, and managed.
13.1 Value Type Example
struct User {
var name: String
}
var firstUser = User(name: "Aarav")
var secondUser = firstUser
secondUser.name = "Meera"
print(firstUser.name)
print(secondUser.name)
Output:
Aarav
Meera
The structure follows value semantics. Assigning firstUser to secondUser creates an independent value from the program's perspective.
13.2 Reference Type Example
class UserProfile {
var name: String
init(name: String) {
self.name = name
}
}
let firstProfile = UserProfile(name: "Aarav")
let secondProfile = firstProfile
secondProfile.name = "Meera"
print(firstProfile.name)
print(secondProfile.name)
Output:
Meera
Meera
Both variables refer to the same class instance. Changing the name through one reference is visible through the other reference.
14. What Is Automatic Reference Counting (ARC)?
Automatic Reference Counting, or ARC, is Swift's memory-management mechanism for class instances. ARC tracks strong references to an object and automatically deallocates that instance when its strong reference count reaches zero.
In most Swift code, developers do not need to manually allocate or free class instances. However, ARC cannot automatically resolve every object relationship. Strong reference cycles can keep objects alive even when the application no longer needs them.
14.1 How ARC Works
- A class instance is created.
- Strong references point to the instance.
- ARC keeps the instance alive while strong references remain.
- When a strong reference is removed or goes out of scope, the reference count can decrease.
- When no strong references remain, the instance becomes eligible for deallocation.
- The class instance's
deinit method runs as the instance is deallocated, allowing appropriate cleanup.
ARC is an automatic system, but developers still need to design object relationships carefully to avoid retaining objects unnecessarily.
15. Understanding Strong References
A strong reference keeps a class instance alive. Strong references are the default when declaring ordinary class-typed properties and variables.
class Book {
let title: String
init(title: String) {
self.title = title
print("\(title) created.")
}
deinit {
print("\(title) deallocated.")
}
}
var firstBook: Book? = Book(title: "Swift Basics")
var secondBook = firstBook
firstBook = nil
print("First reference removed.")
secondBook = nil
print("Second reference removed.")
Possible output:
Swift Basics created.
First reference removed.
Swift Basics deallocated.
Second reference removed.
The object remains alive after firstBook becomes nil because secondBook still holds a strong reference. Once the remaining reference is removed, ARC can deallocate the object.
16. Understanding Weak References
A weak reference does not keep an instance alive. When the referenced object is deallocated, a weak reference is automatically set to nil. Therefore, a weak reference must be declared as an optional variable.
class Teacher {
let name: String
init(name: String) {
self.name = name
}
deinit {
print("Teacher \(name) deallocated.")
}
}
class Classroom {
weak var teacher: Teacher?
init(teacher: Teacher?) {
self.teacher = teacher
}
}
var teacher: Teacher? = Teacher(name: "Priya")
let classroom = Classroom(teacher: teacher)
print(classroom.teacher?.name ?? "No teacher")
teacher = nil
print(classroom.teacher?.name ?? "No teacher")
Output:
Priya
Teacher Priya deallocated.
No teacher
The classroom does not own the teacher. When the only strong reference is removed, the teacher can be deallocated and the weak reference becomes nil.
16.1 When to Use weak
- When an object refers to another object without owning its lifetime.
- When breaking a strong reference cycle between related class instances.
- When a delegate should not be retained by the object that sends delegate callbacks.
- When a closure needs a non-owning reference to an object that may disappear.
17. Understanding Unowned References
An unowned reference also does not keep an object alive. Unlike a weak reference, an unowned reference is generally used when the programmer expects the referenced instance to remain alive whenever the reference is accessed.
class Customer {
let name: String
var card: CreditCard?
init(name: String) {
self.name = name
}
}
class CreditCard {
let number: String
unowned let customer: Customer
init(number: String, customer: Customer) {
self.number = number
self.customer = customer
}
}
var customer: Customer? = Customer(name: "Aarav")
customer!.card = CreditCard(
number: "CARD-1234",
customer: customer!
)
print(customer!.card!.customer.name)
Output:
Aarav
In this example, the customer owns the card through a strong optional property, while the card refers back to the customer using unowned. This prevents the two objects from keeping each other alive indefinitely.
Important: Accessing an unowned reference after its referenced instance has been deallocated is unsafe and can cause a runtime failure. If the referenced object may disappear during normal operation, prefer a weak reference.
18. Strong Reference Cycles
A strong reference cycle occurs when two or more objects hold strong references to one another. ARC cannot deallocate the objects because each object continues to be strongly referenced by another object in the cycle.
18.1 Example of a Strong Reference Cycle
class Person {
let name: String
var apartment: Apartment?
init(name: String) {
self.name = name
}
deinit {
print("Person deallocated.")
}
}
class Apartment {
let unit: String
var tenant: Person?
init(unit: String) {
self.unit = unit
}
deinit {
print("Apartment deallocated.")
}
}
var person: Person? = Person(name: "Aarav")
var apartment: Apartment? = Apartment(unit: "A-101")
person!.apartment = apartment
apartment!.tenant = person
person = nil
apartment = nil
The code creates a cycle: the person strongly retains the apartment, and the apartment strongly retains the person. Setting the external variables to nil does not break the cycle, so neither instance is deallocated.
18.2 Fixing the Cycle with weak
class Person {
let name: String
var apartment: Apartment?
init(name: String) {
self.name = name
}
deinit {
print("Person deallocated.")
}
}
class Apartment {
let unit: String
weak var tenant: Person?
init(unit: String) {
self.unit = unit
}
deinit {
print("Apartment deallocated.")
}
}
var person: Person? = Person(name: "Aarav")
var apartment: Apartment? = Apartment(unit: "A-101")
person!.apartment = apartment
apartment!.tenant = person
person = nil
apartment = nil
In this version, the apartment holds a weak reference to the person. Once the external strong references are removed, the objects can be deallocated instead of retaining each other.
19. ARC and Closures
Closures can capture references to objects. If an object stores a closure and that closure strongly captures the same object, a strong reference cycle can occur.
19.1 Closure Capture Cycle
class DataLoader {
var completion: (() -> Void)?
func configure() {
completion = {
self.finishLoading()
}
}
func finishLoading() {
print("Loading finished.")
}
deinit {
print("DataLoader deallocated.")
}
}
var loader: DataLoader? = DataLoader()
loader?.configure()
loader = nil
The instance stores the closure, and the closure strongly captures the instance through self. This creates a strong reference cycle that prevents deallocation.
19.2 Fixing the Cycle with a Capture List
class DataLoader {
var completion: (() -> Void)?
func configure() {
completion = { [weak self] in
self?.finishLoading()
}
}
func finishLoading() {
print("Loading finished.")
}
deinit {
print("DataLoader deallocated.")
}
}
var loader: DataLoader? = DataLoader()
loader?.configure()
loader = nil
By capturing self weakly, the closure no longer keeps the object alive. When the loader is released, the instance can be deallocated even if its closure property still exists during the release process.
19.3 Choosing Between weak self and unowned self
| Capture |
Behavior |
Best Use |
[weak self] |
Captures a non-owning optional reference. It becomes nil when the object is deallocated. |
When the object may be released before the closure runs. |
[unowned self] |
Captures a non-owning reference that assumes the object remains alive when accessed. |
When the lifetime relationship guarantees the object will remain alive. |
| Strong capture |
Keeps the object alive while the closure retains the reference. |
When retaining the object is intentional and does not create an unwanted cycle. |
20. The Role of deinit
The deinit method is called when a class instance is being deallocated. It can be used for appropriate cleanup and debugging object lifetimes.
class ImageCache {
init() {
print("ImageCache initialized.")
}
deinit {
print("ImageCache released.")
}
}
var cache: ImageCache? = ImageCache()
cache = nil
Output:
ImageCache initialized.
ImageCache released.
Structures and enumerations do not support custom deinit methods. In ordinary Swift code, ARC handles class-instance lifetime automatically; deinit is not a manual memory-release function.
21. Memory Management in iOS View Controllers
Memory management is especially important in iOS applications that use view controllers, delegates, timers, notification observers, asynchronous tasks, and closures.
A view controller may be retained unintentionally by a closure, timer, or service object. If it cannot be released after the user navigates away, memory usage may increase as the user moves through the app.
21.1 Weak Delegate Pattern
import Foundation
protocol DownloadDelegate: AnyObject {
func downloadDidFinish()
}
class DownloadManager {
weak var delegate: DownloadDelegate?
func finishDownload() {
delegate?.downloadDidFinish()
}
}
class DownloadScreen: DownloadDelegate {
let manager = DownloadManager()
init() {
manager.delegate = self
}
func downloadDidFinish() {
print("Download completed.")
}
}
The protocol inherits from AnyObject, which restricts conforming types to class types and allows the delegate property to be weak. The manager can notify the screen without strongly owning it through the delegate property.
21.2 Practical Memory Management Guidelines
- Use weak references for non-owning delegate relationships when appropriate.
- Review closures stored by long-lived objects for accidental strong captures.
- Invalidate timers and stop ongoing work when it is no longer needed.
- Remove notification observers when required by the API and the observer's lifecycle.
- Release large cached data when it is no longer useful.
- Use Xcode's Memory Graph Debugger and Instruments to investigate retained objects.
- Do not add weak references everywhere; choose ownership relationships based on the intended object lifecycle.
22. Error Handling and Memory Management Together
Error handling and memory management work together when an operation can fail while using resources. Swift's scope-based cleanup, ARC, and APIs that manage resources can help ensure that resources do not remain unnecessarily retained after an operation completes or fails.
import Foundation
enum DocumentError: Error {
case emptyDocument
}
func validateDocument(_ content: String) throws -> String {
defer {
print("Document validation finished.")
}
guard !content.isEmpty else {
throw DocumentError.emptyDocument
}
return "Document is valid."
}
do {
print(try validateDocument("Course notes"))
} catch DocumentError.emptyDocument {
print("The document is empty.")
} catch {
print("An unexpected validation error occurred.")
}
Output:
Document validation finished.
Document is valid.
The deferred statement executes when the function exits. If the input were empty, the same cleanup statement would execute before the error reached the caller's catch block.
23. Common Errors and Troubleshooting
| Problem |
Possible Cause |
Solution |
| Unhandled throwing function |
A call to a throwing function is not handled or propagated correctly. |
Use do-catch, try?, or propagate the error from a throwing function. |
| App crashes when using try! |
The operation threw an error unexpectedly. |
Replace forced handling with appropriate error handling. |
| Missing or invalid data |
Input validation or decoding assumptions are incorrect. |
Validate input and handle decoding failures. |
| Object is not deallocated |
A strong reference remains or a strong reference cycle exists. |
Inspect ownership relationships and use weak or unowned references where appropriate. |
| Closure retains a view controller |
A stored closure strongly captures self. |
Review the capture list and consider [weak self]. |
| Unexpected nil access |
A weak reference has become nil or an optional is force-unwrapped. |
Use optional binding or optional chaining. |
| Memory usage increases over time |
Objects, images, caches, timers, or asynchronous operations remain retained. |
Profile the app with Instruments and the Memory Graph Debugger. |
| Cleanup does not run as expected |
The cleanup logic is placed outside the appropriate scope or depends on an incorrect lifecycle assumption. |
Use defer for scope cleanup and understand the lifetime of the owning object. |
24. Best Practices
- Define meaningful custom error types for expected failure conditions.
- Use
do-catch when different errors require different responses.
- Use
try? only when converting an error into nil is appropriate.
- Avoid
try! for operations that may fail at runtime.
- Propagate errors when a higher-level component can make a better decision.
- Use
defer for cleanup that must happen when a scope exits.
- Prefer Swift's safe collection and optional APIs over unsafe assumptions.
- Understand which object owns another object's lifetime.
- Use
weak for non-owning references that may become nil.
- Use
unowned only when its lifetime assumption is guaranteed.
- Inspect closures for strong reference cycles, especially stored callbacks.
- Use Xcode debugging tools to measure memory behavior instead of guessing.
- Keep user-facing errors helpful and technical logs free from sensitive information.
- Test both successful and failed execution paths.
25. Practical Exercises
Exercise 1: Login Error Handling
- Create a custom
LoginError enumeration.
- Write a throwing login-validation function.
- Handle empty credentials and invalid credentials separately.
- Display a clear message for each failure.
Exercise 2: File Processing
- Create a function that reads a local text file.
- Handle missing files and invalid file content.
- Use appropriate resource-management APIs.
- Return a useful result or propagate the error to the caller.
Exercise 3: ARC and Reference Cycles
- Create two classes that hold references to each other.
- Observe what happens when external references are set to nil.
- Fix the cycle by using a weak reference for the non-owning relationship.
- Use
deinit to observe when each instance is released.
Exercise 4: Closure Memory Management
- Create a class that stores a completion closure.
- Capture the owning instance strongly and identify the reference cycle.
- Update the closure with a weak capture list.
- Verify that the object can be deallocated when external references are removed.
26. Mini Project: Reliable Document Manager
Project objective: Build a small document manager that validates document content, reports errors, and demonstrates appropriate object lifetime management.
import Foundation
enum DocumentManagerError: Error {
case emptyTitle
case emptyContent
}
class DocumentManager {
private(set) var savedDocuments: [String: String] = [:]
func saveDocument(title: String, content: String) throws {
let cleanedTitle = title.trimmingCharacters(
in: .whitespacesAndNewlines
)
guard !cleanedTitle.isEmpty else {
throw DocumentManagerError.emptyTitle
}
guard !content.trimmingCharacters(
in: .whitespacesAndNewlines
).isEmpty else {
throw DocumentManagerError.emptyContent
}
savedDocuments[cleanedTitle] = content
print("Document saved: \(cleanedTitle)")
}
func document(named title: String) -> String? {
return savedDocuments[title]
}
deinit {
print("DocumentManager released.")
}
}
let manager = DocumentManager()
do {
try manager.saveDocument(
title: "Swift Notes",
content: "Error handling and ARC"
)
if let content = manager.document(named: "Swift Notes") {
print(content)
}
} catch DocumentManagerError.emptyTitle {
print("Enter a document title.")
} catch DocumentManagerError.emptyContent {
print("Document content cannot be empty.")
} catch {
print("Unable to save the document.")
}
Expected output:
Document saved: Swift Notes
Error handling and ARC
Project Extensions
- Add file reading and writing using
FileManager and throwing Foundation APIs.
- Display validation errors in a SwiftUI interface.
- Add a document list and a computed property for the document count.
- Create a completion closure and ensure it does not introduce an unwanted strong reference cycle.
- Test empty titles, empty content, missing files, and successful saves.
27. Interview Questions and Answers
Q1. What is error handling in Swift?
Error handling is the process of identifying, propagating, and responding to failures using constructs such as throw, throws, try, and do-catch.
Q2. What is the difference between throw and throws?
throw signals a particular error. throws declares that a function or method may propagate an error.
Q3. What is the purpose of do-catch?
It executes potentially throwing code in a controlled scope and handles errors in matching catch blocks.
Q4. What is the difference between try? and try!?
try? converts a thrown error into an optional result. try! assumes success and causes a runtime failure if the operation throws.
Q5. What is defer used for?
defer schedules cleanup or other code to execute when the current scope exits.
Q6. What is ARC?
Automatic Reference Counting manages the lifetime of Swift class instances by tracking strong references.
Q7. Does ARC manage structures in the same way as class instances?
No. ARC's reference counting applies to class instances. Structures and enumerations follow value semantics, although their stored values may themselves contain references to class instances.
Q8. What is a strong reference?
A strong reference keeps a class instance alive while that reference remains.
Q9. What is a weak reference?
A weak reference does not keep an instance alive and automatically becomes nil when the instance is deallocated. It must be declared as an optional variable.
Q10. What is an unowned reference?
An unowned reference does not retain an instance and assumes that the instance will remain alive when the reference is accessed. Violating this assumption can cause a runtime failure.
Q11. What is a strong reference cycle?
It is a situation in which two or more objects strongly retain one another, preventing ARC from deallocating them.
Q12. How can a closure cause a memory leak?
A closure can strongly capture an object that stores the closure, creating a cycle. A weak or, where lifetime guarantees permit, unowned capture can break that cycle.
Q13. What is the purpose of deinit?
deinit runs when a class instance is being deallocated and can perform appropriate final cleanup or assist with debugging object lifetimes.
Q14. How do you find memory leaks in an iOS app?
Use Xcode's Memory Graph Debugger and Instruments to inspect retained objects, reference paths, allocations, and memory growth.
Q15. Why should try! be avoided in network code?
Network operations can fail because of connectivity, server errors, timeouts, or invalid responses. Using try! for such operations can crash the app rather than handle the failure.
28. Quick Revision Checklist
- Define custom errors by conforming to the
Error protocol.
- Use
throw to signal an error and throws to declare a throwing function.
- Use
try with appropriate error handling or propagation.
- Use
do-catch to handle specific error cases.
- Use
try? when an optional result is sufficient.
- Avoid
try! unless success is genuinely guaranteed.
- Use
defer for scope-based cleanup.
- ARC manages class-instance lifetimes through strong references.
- Strong references retain objects; weak and unowned references do not.
- Use weak references to break appropriate strong reference cycles.
- Review closures for unintended strong captures.
- Use
deinit to observe class-instance deallocation.
- Use Xcode tools to investigate memory problems.
- Test both successful operations and failure scenarios.
29. Conclusion
Error handling, memory management, and ARC are essential skills for building stable iOS applications with Swift. Error handling makes failures explicit and allows applications to respond appropriately. ARC manages class-instance lifetimes automatically, while understanding strong, weak, and unowned references helps prevent strong reference cycles and unnecessary memory retention.
Combining these techniques with structured cleanup, sensible closure capture lists, custom error types, and regular debugging produces code that is safer, more maintainable, and easier to test. Practice the examples and mini project to strengthen your understanding of Swift's runtime behavior and real-world iOS development.
30. Additional Learning Resources